Appearance
Java 异常处理机制 — 完整技术笔记
一、概述
Java 异常处理机制是 Java 语言通过 Throwable 类体系,将程序正常业务逻辑与错误处理代码分离的一套标准化方案。它解决的核心问题是:在运行时遇到可预见或不可预见的错误条件时,如何优雅地中断当前流程、传递错误信息,并在合适的层次进行恢复或终止。
完善的异常处理是编写工业级代码的基本要求。反之,异常的不当使用(吞异常、过度捕获、丢失异常链)是线上问题难以排查的最常见根源之一。
二、核心概念 / 原理:异常体系结构
2.1 体系全景
Java 异常体系以 Throwable 为根,分为两大分支:
mermaid
classDiagram
Throwable <|-- Error
Throwable <|-- Exception
Exception <|-- IOException
Exception <|-- SQLException
Exception <|-- RuntimeException
RuntimeException <|-- NullPointerException
RuntimeException <|-- IllegalArgumentException
RuntimeException <|-- ClassCastException
IllegalArgumentException <|-- NumberFormatException
Error <|-- OutOfMemoryError
Error <|-- StackOverflowError
Error <|-- NoClassDefFoundError
class Throwable {
+String getMessage()
+String getLocalizedMessage()
+Throwable getCause()
+void printStackTrace()
+Throwable[] getSuppressed()
}
class Error {
<<不可恢复 - 系统级>>
}
class Exception {
<<可恢复 - 应用级>>
}
class RuntimeException {
<<Unchecked 非受检>>
}2.2 Error 与 Exception 的本质区别
| 维度 | Error | Exception |
|---|---|---|
| 性质 | JVM / 系统级严重错误 | 应用程序可处理的异常情况 |
| 可恢复性 | 通常不可恢复 | 可恢复,应针对性处理 |
| 是否应捕获 | 一般不应捕获(最外层打日志后快速失败) | 应捕获并处理 |
| 典型代表 | OutOfMemoryError、StackOverflowError、NoClassDefFoundError | IOException、NullPointerException、SQLException |
注意:
StackOverflowError通常由无限递归引起;OutOfMemoryError表示堆或元空间耗尽。应用程序顶多在最外层catch (Throwable t)打印日志后立即终止,不应试图从Error中恢复。
2.3 受检异常 vs 非受检异常
| 特性 | Checked Exception | Unchecked Exception |
|---|---|---|
| 定义 | Exception 的子类(排除 RuntimeException) | RuntimeException 及其子类 |
| 编译器检查 | ✅ 强制要求 try-catch 或 throws | ❌ 编译器不强制 |
| 设计意图 | 提醒调用方处理可预见的异常 | 通常是编程错误,应通过代码逻辑避免 |
| 典型代表 | IOException、SQLException、ClassNotFoundException | NullPointerException、IllegalArgumentException |
| 现代趋势 | Spring 等框架倾向包装为 RuntimeException 抛出 | 优先使用,减少样板代码 |
常见 RuntimeException 速查表:
| 异常类 | 触发场景 |
|---|---|
NullPointerException | 对 null 引用调用方法或访问属性 |
ArrayIndexOutOfBoundsException | 数组索引超出范围 |
ClassCastException | 不兼容类型的强制转换 |
NumberFormatException | 字符串无法转换为数字(IllegalArgumentException 子类) |
IllegalArgumentException | 方法接收到非法参数 |
IllegalStateException | 方法在不合法的状态下被调用 |
UnsupportedOperationException | 调用了不支持的操作(如修改不可变集合) |
ArithmeticException | 算术异常(如除以零) |
2.4 异常的传播机制
异常从抛出点沿调用栈向上逐层传播,直到被 catch 块捕获,或到达 main 方法导致线程终止。
mermaid
sequenceDiagram
participant main
participant methodA
participant methodB
participant methodC
main->>methodA: 调用
methodA->>methodB: 调用
methodB->>methodC: 调用
methodC->>methodC: throw new IOException()
methodC-->>methodB: 异常向上传播
methodB-->>methodA: 未捕获,继续传播
methodA->>methodA: catch(IOException e) 捕获处理
methodA-->>main: 正常返回关键点:
- 每个异常对象在创建时会记录当前线程的调用栈快照(StackTrace),这是排查问题的核心信息
- 未被捕获的异常最终由线程的
UncaughtExceptionHandler处理(可设置自定义 Handler 用于监控告警)
java
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
log.error("线程 {} 发生未捕获异常", thread.getName(), throwable);
// 发送告警通知
});2.5 Throwable 类常用方法
| 方法 | 说明 |
|---|---|
String getMessage() | 返回异常的详细描述信息 |
String toString() | 返回异常的简要描述(类名 + message) |
String getLocalizedMessage() | 返回本地化信息,子类可覆盖实现国际化 |
void printStackTrace() | 在控制台打印完整的异常栈信息 |
Throwable getCause() | 获取原始异常(异常链) |
Throwable[] getSuppressed() | 获取被抑制的异常数组(Java 7+) |
三、关键知识点详解
3.1 try-catch-finally
java
try {
// 可能抛出异常的代码
int result = 10 / 0;
} catch (ArithmeticException e) {
// 捕获特定异常并处理
System.out.println("算术异常: " + e.getMessage());
} catch (Exception e) {
// 更宽泛的异常兜底(子类 catch 必须在父类之前)
System.out.println("通用异常: " + e.getMessage());
} finally {
// 无论是否异常都执行,用于资源清理
System.out.println("Finally 执行");
}Java 7 多异常合并语法:
java
catch (IOException | SQLException e) {
log.error("IO或SQL异常", e);
}注意:多异常合并时,变量
e的类型是这些异常的共同父类,且e是隐式final的,不可重新赋值。
3.1.1 finally 的执行保证与陷阱
finally 不执行的极端情况:
System.exit()导致 JVM 进程终止- 线程被强制
kill - JVM 崩溃(如
OutOfMemoryError导致进程直接终止)
⚠️ return 陷阱(高频面试考点):
java
public int getInt() {
int i = 0;
try {
i = 1;
return i; // try 中的 return
} finally {
i = 2;
return i; // finally 中的 return,覆盖了 try 的返回值
}
}
// 返回值:2java
public int getInt2() {
int i = 0;
try {
i = 1;
return i; // 返回值在执行 finally 前已被"快照"
} finally {
i = 2; // 修改了 i,但不影响已快照的返回值
}
}
// 返回值:1字节码层面解释:
getInt() 的字节码关键流程:
0 iconst_0 // 压入 0
1 istore_1 // i = 0
2 iconst_1 // 压入 1
3 istore_1 // i = 1
4 iload_1 // 加载 i(值为 1)
5 istore_2 // 快照返回值到临时变量
6 iconst_2 // 压入 2
7 istore_1 // i = 2(finally 修改了 i)
8 iload_1 // 加载 i(值为 2)
9 ireturn // 返回 2 ← finally 的 return 覆盖了 try 的 return核心原理:编译器在字节码层面将
finally块的代码复制展开到每一条可能的退出路径(正常 return + 每个 catch 路径 + 异常退出路径)之后。如果finally中有return,其指令在try的return之后执行,从而覆盖返回值。可通过javap -c清晰看到这种代码复制结构。
🔑 铁律:绝对不要在 finally 中使用 return 或 throw,否则会覆盖 try/catch 中的返回值或抑制原始异常。
异常抑制问题:
java
try {
throw new IOException("原始异常");
} finally {
throw new RuntimeException("finally中的异常");
// 原始 IOException 被"抑制",无法被外层感知!
}3.2 try-with-resources(Java 7+)
对实现了 AutoCloseable 接口的资源,try-with-resources 自动在 try 块结束后调用 close(),无需 finally 手动关闭。
java
try (FileInputStream in = new FileInputStream("test.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
String line = reader.readLine();
System.out.println(line);
} catch (IOException e) {
e.printStackTrace();
}
// 资源按声明的逆序自动关闭:先关 reader,再关 in异常抑制机制:当 try 块和 close() 同时抛出异常时,close() 的异常会被抑制(Suppressed),附加到原始异常上,原始异常信息不丢失:
java
try (MyResource resource = new MyResource()) {
throw new IOException("业务异常");
} catch (IOException e) {
System.out.println("主异常: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("被抑制的异常: " + suppressed.getMessage());
}
}反编译后的本质:编译器将 try-with-resources 转换为嵌套的 try-catch-finally 结构,并通过 addSuppressed() 保留被抑制的异常。
3.2.1 AutoCloseable 与 Closeable 的区别
| 特性 | AutoCloseable | Closeable |
|---|---|---|
| 所在包 | java.lang | java.io |
| 继承关系 | 根接口 | 继承自 AutoCloseable |
| close() 异常 | 可抛任意 Exception | 只能抛 IOException |
| 适用场景 | 通用资源(数据库连接、锁等) | IO 相关资源 |
| 幂等性要求 | close() 应设计为幂等(多次调用安全) | 同左 |
3.3 throw 与 throws
| 比较维度 | throw | throws |
|---|---|---|
| 位置 | 方法体内部 | 方法签名声明处 |
| 作用 | 实际抛出一个异常对象 | 声明方法可能抛出的异常类型 |
| 后跟内容 | 异常实例:throw new XxxException() | 异常类型:throws IOException, SQLException |
| 执行效果 | 立即中断当前方法,控制权转移到调用栈上层 | 仅为静态声明,不会直接触发异常 |
| 使用约束 | 受检异常 throw 后必须配合 catches 或 throws | 受检异常必须声明;RuntimeException 可选 |
java
public void transfer(BigDecimal amount) throws InsufficientBalanceException {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("转账金额必须大于0");
}
if (balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException("余额不足", balance, amount);
}
// 正常转账逻辑...
}3.4 受检异常在 Lambda 中的处理
函数式接口(如 Function<T, R>、Consumer<T>)的抽象方法未声明 throws,导致 Lambda 不能直接抛出受检异常。
方案一:内部 catch 包装为 RuntimeException
java
list.forEach(item -> {
try {
processWithIO(item);
} catch (IOException e) {
throw new UncheckedIOException(e); // 包装为非受检异常
}
});方案二:自定义能抛受检异常的函数式接口
java
@FunctionalInterface
public interface ThrowingConsumer<T, E extends Exception> {
void accept(T t) throws E;
}
public static <T> Consumer<T> wrap(ThrowingConsumer<T, Exception> consumer) {
return t -> {
try {
consumer.accept(t);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
// 使用
list.forEach(wrap(item -> processWithIO(item)));方案三:Lombok @SneakyThrows
java
list.forEach(item -> processItem(item));
@SneakyThrows
private void processItem(String item) {
// 可以直接抛出受检异常,Lombok 通过字节码操作绕过编译器检查
Files.readString(Path.of(item));
}注意:
@SneakyThrows本质是利用泛型擦除的技巧在字节码层面绕过检查,需谨慎使用,避免调用方完全无法感知受检异常的存在。
四、自定义异常设计
4.1 为什么要自定义异常
业务系统需要用异常表达领域语义,如 OrderNotFoundException、InsufficientStockException。Java 内置异常无法精确描述业务含义,也无法携带错误码等业务信息。
4.2 推荐的异常层次设计
mermaid
classDiagram
RuntimeException <|-- BaseException
BaseException <|-- BusinessException
BaseException <|-- SystemException
BusinessException <|-- OrderException
BusinessException <|-- UserException
OrderException <|-- OrderNotFoundException
OrderException <|-- InsufficientStockException
class BaseException {
-ErrorCode errorCode
-String message
+BaseException(ErrorCode, String)
+BaseException(ErrorCode, String, Throwable)
}
class ErrorCode {
<<enumeration>>
+String code
+String description
+ErrorLevel level
}4.3 实战代码示例
错误码枚举:
java
public enum ErrorCode {
SUCCESS("0", "成功", ErrorLevel.INFO),
PARAM_ERROR("400", "请求参数错误", ErrorLevel.INFO),
UNAUTHORIZED("403", "无权限", ErrorLevel.WARN),
NOT_FOUND("601", "数据不存在", ErrorLevel.ERROR),
SYSTEM_ERROR("500", "系统异常", ErrorLevel.ERROR),
RPC_ERROR("502", "远程调用异常", ErrorLevel.ERROR);
private final String code;
private final String description;
private final ErrorLevel level;
ErrorCode(String code, String description, ErrorLevel level) {
this.code = code;
this.description = description;
this.level = level;
}
// getter...
}基础异常类:
java
public class BaseException extends RuntimeException {
private final ErrorCode errorCode;
public BaseException(ErrorCode errorCode) {
super(errorCode.getDescription());
this.errorCode = errorCode;
}
public BaseException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
// ⚠️ 关键:保留原始异常链
public BaseException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}具体业务异常:
java
public class OrderNotFoundException extends BaseException {
public OrderNotFoundException(String orderId) {
super(ErrorCode.NOT_FOUND, "订单不存在: " + orderId);
}
}4.4 异常链(Cause)的重要性
❌ 错误做法 — 吞掉原始异常:
java
try {
orderRepository.findById(id);
} catch (DataAccessException e) {
// 原始异常的 StackTrace 丢失!排查问题时无法定位根因
throw new OrderNotFoundException("查询订单失败");
}✅ 正确做法 — 保留异常链:
java
try {
orderRepository.findById(id);
} catch (DataAccessException e) {
// 原始异常作为 cause 传入,完整保留调用链
throw new BaseException(ErrorCode.SYSTEM_ERROR, "查询订单失败", e);
}通过 getCause() 可以递归获取原始异常,printStackTrace() 会打印完整的 cause 链:
com.example.BaseException: 查询订单失败
at com.example.OrderService.findOrder(OrderService.java:42)
...
Caused by: org.springframework.dao.DataAccessException: Connection refused
at org.springframework.jdbc...
...
Caused by: java.net.ConnectException: Connection refused
at java.base/java.net.Socket.connect(Socket.java:633)
...4.5 全局异常处理(Spring Boot)
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BaseException.class)
public ResponseEntity<ErrorResponse> handleBaseException(BaseException e) {
ErrorCode code = e.getErrorCode();
switch (code.getLevel()) {
case INFO -> log.info("业务提示: {}", e.getMessage());
case WARN -> log.warn("业务警告: {}", e.getMessage());
case ERROR -> log.error("业务异常: {}", e.getMessage(), e);
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(code.getCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("系统未知异常", e);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("500", "系统异常,请联系管理员"));
}
}五、最佳实践 / 常见坑
5.1 异常处理最佳实践总结
| # | 实践原则 | 说明 |
|---|---|---|
| 1 | 不要吞异常 | catch 后必须处理(记录日志 / 抛出 / 恢复),绝不留空 catch |
| 2 | 不要用异常控制流程 | 异常创建涉及填充栈帧,比条件判断慢 100 倍以上 |
| 3 | 精确捕获异常类型 | 避免 catch(Exception e) 大包大揽,区分业务异常与系统异常 |
| 4 | 日志记录包含完整异常 | log.error("msg", e) 而非 log.error(e.getMessage()) |
| 5 | 在 API 边界转换异常 | 技术异常 → 业务异常,不向外暴露内部实现细节 |
| 6 | 不在 finally 中 return/throw | 会抑制原始异常或覆盖返回值 |
| 7 | 使用 try-with-resources | 所有 AutoCloseable 资源必须使用此语法 |
| 8 | 保留异常链 | throw new XxxException("msg", cause) 不要丢弃 cause |
| 9 | 尽可能晚地捕获异常 | 让异常传播到最合适的层次统一处理 |
| 10 | 不要重复记录同一异常 | 避免每层 catch 都打日志,统一交由最上层处理 |
5.2 常见反模式与修正
反模式 1:吃掉异常
java
// ❌ 异常被静默吞掉,线上出问题完全无法排查
try {
riskyOperation();
} catch (Exception e) {
// 什么都没做
}
// ✅ 至少记录日志
try {
riskyOperation();
} catch (Exception e) {
log.error("riskyOperation 执行失败", e);
throw e;
}反模式 2:日志信息不完整
java
// ❌ 只有 message,丢失了 StackTrace
log.error("处理失败: " + e.getMessage());
// ✅ 传入异常对象,打印完整栈信息
log.error("处理失败, 参数: {}", param, e);反模式 3:既记日志又抛异常(导致重复打印)
java
// ❌ 下层打一次日志,上层再打一次,同一异常出现两条日志
try {
service.process();
} catch (Exception e) {
log.error("处理失败", e); // 日志 1
throw e; // 上层 catch 又会打日志 2
}
// ✅ 选择其一:打日志或抛异常
// 方案 A:下层只抛,上层统一打
throw new ServiceException("处理失败", e);
// 方案 B:下层打日志并恢复,不再抛出
log.error("处理失败,使用降级方案", e);
return fallbackResult;反模式 4:异常信息丢失
java
// ❌ 只保留了 message,丢失了原始异常的完整栈
throw new ServiceException(e.getMessage());
// ✅ 保留原始异常作为 cause
throw new ServiceException("服务处理失败", e);六、对比 / 易混淆点
6.1 finally vs try-with-resources
| 维度 | finally | try-with-resources |
|---|---|---|
| 资源关闭 | 手动在 finally 中调用 close() | 自动关闭(实现 AutoCloseable) |
| 异常抑制 | finally 异常覆盖 try 异常,原始异常丢失 | close() 异常被 addSuppressed(),原始异常保留 |
| 代码量 | 繁琐,需 null 检查 | 简洁一行声明 |
| 关闭顺序 | 需手动控制 | 按声明的逆序自动关闭 |
| 推荐度 | 仅用于非资源场景的清理 | 所有资源操作首选 |
6.2 Checked vs Unchecked 选择指南
mermaid
flowchart TD
A[调用方能否合理恢复?] -->|能| B[使用 Checked Exception]
A -->|不能| C[使用 Unchecked Exception]
B --> D[如: 文件不存在 → FileNotFoundException<br/>调用方可提示用户重新选择]
C --> E[如: 空指针 → NullPointerException<br/>这是代码 Bug,应修复代码]
style B fill:#e1f5fe
style C fill:#fff3e0现代实践:Spring、MyBatis 等主流框架已全面转向
RuntimeException。自定义业务异常建议继承RuntimeException,减少样板代码,提升函数式编程兼容性。
七、面试高频问题
Q1:Java 异常体系的层次结构是怎样的?Error 和 Exception 有什么区别?
答:Throwable 是所有异常的根类,分为 Error 和 Exception。Error 表示 JVM 级别的严重错误(如 OutOfMemoryError、StackOverflowError),应用程序不应捕获;Exception 表示应用层面可处理的异常,又分为受检异常(编译器强制处理)和非受检异常(RuntimeException,编译器不强制)。
Q2:finally 块一定会执行吗?finally 中 return 会怎样?
答:除 System.exit()、线程被 kill、JVM 崩溃外,finally 一定执行。若 try 和 finally 都有 return,最终返回 finally 的值——因为在字节码层面,finally 的代码被复制展开到每条退出路径后面,finally 的 return 指令在 try 的 return 之后执行从而覆盖返回值。应绝对避免在 finally 中 return。
Q3:try-with-resources 的原理是什么?和 finally 相比有什么优势?
答:try-with-resources 要求资源实现 AutoCloseable 接口,编译器会将其转换为嵌套的 try-catch-finally 结构,自动调用 close()。核心优势在于:代码简洁、自动关闭、异常抑制机制——当 try 块和 close() 同时抛异常时,close() 的异常通过 addSuppressed() 附加到原始异常上,不会丢失任何异常信息。
Q4:什么时候应该用 Checked Exception,什么时候用 Unchecked Exception?
答:当调用方可以且应该对异常进行合理恢复时(如文件不存在让用户重新选择),使用受检异常。当异常代表编程错误(如空指针、参数非法)或调用方无法合理恢复时,使用运行时异常。现代 Java 开发的趋势是优先使用运行时异常,配合全局异常处理器统一处理。
Q5:为什么捕获异常后重新抛出时必须保留异常链?
答:异常链通过 Throwable.getCause() 保留了完整的根因信息和调用栈。如果不传入原始异常作为 cause(如 throw new ServiceException("msg")),原始异常的 StackTrace 将完全丢失,线上排查问题时无法定位到真正的错误源头。正确做法是 throw new ServiceException("msg", originalException)。
八、总结
┌──────────────────────────────────────────────────────────────┐
│ Java 异常处理机制要点 │
├──────────────────────────────────────────────────────────────┤
│ 体系结构:Throwable → Error(不捕获) / Exception(需处理) │
│ Exception → Checked(编译器强制) / Unchecked(不强制) │
├──────────────────────────────────────────────────────────────┤
│ 处理语句:try-catch-finally / try-with-resources(首选) │
│ throw(抛出异常) / throws(声明异常) │
├──────────────────────────────────────────────────────────────┤
│ 核心陷阱:finally 中不要 return/throw │
│ 不要吞异常、不要丢失异常链 │
│ 不要同时记日志又抛异常 │
├──────────────────────────────────────────────────────────────┤
│ 自定义异常:继承 RuntimeException + 错误码枚举 + 保留 cause │
│ 全局处理:@ControllerAdvice + @ExceptionHandler 统一拦截 │
├──────────────────────────────────────────────────────────────┤
│ 黄金法则:精确捕获、保留异常链、尽可能晚捕获、统一处理 │
└──────────────────────────────────────────────────────────────┘一句话记忆:异常处理的本质是在正确的层次、以正确的方式、处理正确类型的异常,同时保留完整的错误上下文信息。